package net.sudot.commons.lock;

import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.data.redis.serializer.RedisSerializer;

import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;

/**
 * 使用Redis服务实现的分布式锁
 *
 * @author tangjialin on 2018-03-18.
 */
public class RedisLock implements java.util.concurrent.locks.Lock, AutoCloseable {
    /** 锁的默认过期时长:10秒 */
    private static final Duration DEFAULT_EXPIRATION_TIME = Duration.ofSeconds(10L);
    /** 锁实际的过期时长 */
    private final Duration expirationTime;
    /** 锁的KEY */
    private final String key;
    private final RedisTemplate<?, ?> redisTemplate;
    private String lockValue;

    /**
     * 构造函数
     *
     * @param redisTemplate RedisTemplate
     * @param name          锁的命名
     * @param key           锁的key
     */
    public RedisLock(RedisTemplate<?, ?> redisTemplate, String name, String key) {
        this(redisTemplate, name, key, DEFAULT_EXPIRATION_TIME);
    }

    /**
     * 构造函数
     *
     * @param redisTemplate  RedisTemplate
     * @param name           锁的命名
     * @param key            锁的key
     * @param expirationTime 锁的过期时间,若在此时间内未解锁,则自动解锁
     */
    public RedisLock(RedisTemplate<?, ?> redisTemplate, String name, String key, Duration expirationTime) {
        this.redisTemplate = redisTemplate;
        this.key = (key == null || key.isEmpty()) ? name : name + ":" + key;
        this.expirationTime = expirationTime;
    }

    /**
     * 释放锁
     * <h2>方案一(有缺陷,已由方案二代替):</h2>
     * <pre>
     * 方案一的问题在于:
     * 如果调用jedis.del()方法的时候，这把锁已经不属于当前客户端的时候会解除他人加的锁。
     * 那么是否真的有这种场景？答案是肯定的。
     * 比如客户端A加锁，一段时间之后客户端A解锁，在执行jedis.del()之前，锁突然过期了。
     * 此时客户端B尝试加锁成功，然后客户端A再执行del()方法，则将客户端B的锁给解除了。
     *
     * // 若获得的锁小于当前时间,则该锁已过期,无须释放锁(加1秒,延迟处理)
     * if (lock + 1000L <= System.currentTimeMillis()) { return; }
     * Long andSet = (Long) redisTemplate.opsForValue().getAndSet(key, lock);
     * if (andSet != null && andSet.longValue() == lock) {
     *     redisTemplate.delete(key);
     * }
     * </pre>
     *
     *
     * <h2>方案二:</h2>
     * <pre>
     * Jedis调用原生API执行Lua脚本实现方式:
     * String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
     * Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
     * Long success = 1L;
     * return success.equals(result);
     *
     * redisTemplate实现方式:
     * redisTemplate.execute((RedisCallback<Boolean>) connection -> {
     *     RedisSerializer<String> serializer = redisTemplate.getStringSerializer();
     *     String command = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
     *     return connection.eval(serializer.serialize(command), ReturnType.BOOLEAN, 1, serializer.serialize(key), serializer.serialize(lockValue));
     * });
     * </pre>
     *
     * @param key       锁
     * @param lockValue 获得的锁
     */
    private void releasableLock(String key, String lockValue) {
        redisTemplate.execute((RedisCallback<Boolean>) connection -> {
            RedisSerializer<String> serializer = redisTemplate.getStringSerializer();
            String command = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            return connection.eval(serializer.serialize(command), ReturnType.BOOLEAN, 1, serializer.serialize(key), serializer.serialize(lockValue));
        });
    }

    /**
     * 获得执行锁
     * <h2>方案一(有缺陷,已由方案二代替):</h2>
     * <pre>
     * 方案一的问题在于:
     * 1. 由于是客户端自己生成过期时间，所以需要强制要求分布式下每个客户端的时间必须同步。
     * 2. 当锁过期的时候，如果多个客户端同时执行jedis.getSet()方法，那么虽然最终只有一个客户端可以加锁，但是这个客户端的锁的过期时间可能被其他客户端覆盖。
     * 3. 锁不具备拥有者标识，即任何客户端都可以解锁。
     *
     * 以下为方案一实现逻辑:
     * A 向 lock.foo 发送 SETNX 命令。
     * 因为崩溃掉的 B 还锁着 lock.foo ，所以 Redis 向 A 返回 0 。
     * A 向 lock.foo 发送 GET 命令，查看 lock.foo 的锁是否过期。如果不，则休眠(sleep)一段时间，并在之后重试。
     * 另一方面，如果 lock.foo 内的 unix 时间戳比当前时间戳老，A 执行以下命令：
     * GETSET lock.foo <current Unix timestamp + lock timeout + 1>
     *
     * 因为 GETSET 的作用，A 可以检查看 GETSET 的返回值，确定 lock.foo 之前储存的旧值仍是那个过期时间戳，如果是的话，那么 A 获得锁。
     * 如果其他客户端，比如 C，比 A 更快地执行了 GETSET 操作并获得锁，那么 A 的 GETSET 操作返回的就是一个未过期的时间戳(C 设置的时间戳)。A 只好从第一步开始重试。
     * 注意，即便 A 的 GETSET 操作对 key 进行了修改，这对未来也没什么影响。
     *
     * 这里假设锁key对应的value没有实际业务意义，否则会有问题，而且其实其value也确实不应该用在业务中。
     *
     * // 系统时间
     * long timeMillis = System.currentTimeMillis();
     * // 过期时间点(如果获得锁之后,在什么时候未释放算过期)
     * long expirationTime = timeMillis + 1000L * expirationPeriod;
     * ValueOperations<String, Object> opsForValue = redisTemplate.opsForValue();
     * // 设置过期时间并返回操作结果,若为true则表示成功获得锁
     * if (opsForValue.setIfAbsent(key, expirationTime)) {
     *     // 成功获得锁之后,设置锁本身的过期时间,防止锁一直存在于redis中
     *     redisTemplate.expire(key, expirationPeriod, TimeUnit.SECONDS);
     *     return expirationTime;
     * }
     * // 未获得锁,获得当前锁的过期时间
     * Long oldExpirationTime = (Long) opsForValue.get(key);
     * oldExpirationTime = oldExpirationTime == null ? 0 : oldExpirationTime;
     * // 若timeMillis < oldExpirationTime,则该锁未过期,获取锁失败
     * if (timeMillis < oldExpirationTime) { return 0; }
     * // 当锁已过期,尝试获得锁,并返回上一次锁的过期时间
     * Long andSet = (Long) opsForValue.getAndSet(key, expirationTime);
     * if (andSet == null) { return expirationTime; }
     * // 若oldExpirationTime == andSet,则表示获得了锁,否则表示锁被其它操作获得
     * return oldExpirationTime.longValue() == andSet.longValue() ? expirationTime : 0;
     * </pre>
     *
     *
     * <h2>方案二:</h2>
     * <pre>
     * Jedis原生API实现方式:
     * // expireTime单位:毫秒
     * String result = jedis.set(lockKey, lockMark, "NX", "PX", expiration.toMillis());
     * // expireTime单位:秒
     * String result = jedis.set(lockKey, lockMark, "NX", "EX", expiration.getSeconds());
     * return "OK".equals(result);
     *
     * redisTemplate实现方式:
     * return redisTemplate.execute(connection -> {
     *     RedisSerializer<String> serializer = redisTemplate.getStringSerializer();
     *     String value = String.valueOf(System.currentTimeMillis());
     *     byte[] rawKey = serializer.serialize(key);
     *     byte[] rawValue = serializer.serialize(value);
     *     // 系统时间
     *     Boolean set = connection.set(rawKey, rawValue, Expiration.from(expiration), RedisStringCommands.SetOption.SET_IF_ABSENT);
     *     return Boolean.TRUE.equals(set) ? value : null;
     * }, true);
     *
     * Redis指令说明
     *  SET key value [EX seconds] [PX milliseconds] [NX|XX]
     *  将字符串值 value 关联到 key 。
     *  如果 key 已经持有其他值， SET 就覆写旧值，无视类型。
     *  对于某个原本带有生存时间（TTL）的键来说， 当 SET 命令成功在这个键上执行时， 这个键原有的 TTL 将被清除。
     *  可选参数
     *  从 Redis 2.6.12 版本开始， SET 命令的行为可以通过一系列参数来修改：
     *  EX second ：设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
     *  PX millisecond ：设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
     *  NX ：只在键不存在时，才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
     *  XX ：只在键已经存在时，才对键进行设置操作。
     * </pre>
     *
     * @param key        锁
     * @param expiration 锁的过期时间,若在此时间内未解锁,则自动解锁
     * @return 返回过期的时间.返回0表示未成功加锁
     */
    private String lock(String key, Duration expiration) {
        return redisTemplate.execute(connection -> {
            RedisSerializer<String> serializer = redisTemplate.getStringSerializer();
            String value = String.valueOf(System.currentTimeMillis());
            byte[] rawKey = serializer.serialize(key);
            byte[] rawValue = serializer.serialize(value);
            // 系统时间
            Boolean set = connection.set(rawKey, rawValue, Expiration.from(expiration), RedisStringCommands.SetOption.SET_IF_ABSENT);
            return Boolean.TRUE.equals(set) ? value : null;
        }, true);
    }

    @Override
    public void lock() {
        while (!tryLock()) {
            try {Thread.sleep(100);} catch (InterruptedException e) {}
        }
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean tryLock() {
        lockValue = lock(key, expirationTime);
        return lockValue != null;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time <= 0) { return tryLock(); }
        time = System.currentTimeMillis() + unit.toMillis(time);
        while (!tryLock() && time > System.currentTimeMillis()) {
            Thread.sleep(100);
        }
        return lockValue != null;
    }

    @Override
    public void unlock() {
        releasableLock(key, lockValue);
    }

    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException();
    }

    public String getKey() {
        return key;
    }

    @Override
    public void close() throws Exception {
        this.unlock();
    }
}
